Cron 작업의 중복 실행 방지

Cron 작업의 중복 실행 방지

한눈에 보기

서버 인스턴스마다 Cron을 등록하면 같은 시각에 작업이 여러 번 실행된다. 단일 프로세스의 running 플래그, 데이터베이스의 실행 슬롯 Unique Constraint, Advisory Lock, 전용 스케줄러는 서로 다른 범위의 문제를 해결한다. 어느 방법을 쓰더라도 프로세스 중단과 타임아웃 때문에 작업 자체는 재실행 가능하고 멱등하게 만들어야 한다.

목차

Cron 표현식은 실행 횟수를 보장하지 않는다

다음 코드는 매일 새벽 2시에 정산 작업을 시작한다.

cron.schedule("0 2 * * *", async () => {
  await settlementService.runDaily();
});

개발 환경에서 서버 한 대로 실행하면 한 번만 동작한다. 운영에서 애플리케이션을 세 개의 replica로 늘리면 각 프로세스가 자신의 타이머를 등록한다.

sequenceDiagram
    participant A as API Pod A
    participant B as API Pod B
    participant C as API Pod C
    participant DB as Database

    Note over A,C: 02:00:00
    A->>DB: daily settlement 실행
    B->>DB: daily settlement 실행
    C->>DB: daily settlement 실행

정산 이메일이 세 번 발송되고, 외부 송금 API가 세 번 호출되고, 같은 집계 행을 서로 덮어쓸 수 있다.

Cron 라이브러리는 대개 “이 프로세스 안에서 언제 콜백을 호출할 것인가”를 결정한다. 다음은 보장하지 않는다.

스케줄과 실행 보장은 다르다

Cron 표현식은 시간 규칙이다. 리더 선출, 중복 방지, 내구성, 재시도, 멱등성은 별도의 실행 시스템이 책임져야 한다.

먼저 중복의 종류를 구분한다

“Cron이 중복 실행됐다”는 말에는 여러 상황이 섞여 있다.

여러 replica가 동시에 실행

같은 서비스 인스턴스가 모두 스케줄러를 포함할 때 발생한다.

02:00 Pod A 시작
02:00 Pod B 시작
02:00 Pod C 시작

이전 실행이 끝나기 전에 다음 주기가 시작

한 프로세스에서도 발생한다.

10:00 작업 A 시작
10:01 작업 A 아직 실행 중
10:01 다음 작업 A 시작

실패 재시도와 원래 스케줄이 겹침

10:00 실행 실패 → 10:05 재시도 예약
10:05 정규 스케줄 실행 + 재시도 실행

타임아웃 뒤 실제 작업은 계속 실행

호출자는 실패로 판단해 재시도하지만 외부 시스템은 첫 요청을 처리 중일 수 있다.

작업 → 외부 API 요청 → 응답 timeout
작업은 실패로 기록 → 재시도
외부 API는 첫 요청도 완료 → 부작용 두 번

운영자가 수동 재실행

실패한 날짜를 복구하려고 실행했는데 기존 작업이 아직 살아 있을 수 있다.

종류마다 필요한 키가 다르다. 단순히 jobName 하나만 잠그면 모든 날짜의 재처리가 막힐 수 있고, 반대로 날짜만 사용하면 같은 날짜의 서로 다른 테넌트 작업이 불필요하게 직렬화될 수 있다.

실행 단위를 명시한다.

type JobIdentity = Readonly<{
  jobName: "daily-settlement";
  scheduleSlot: string; // 예: 2025-08-05
  tenantId?: string;
}>;

중복 여부를 결정하는 키는 보통 다음 조합이다.

job_name + schedule_slot + optional_partition

단일 프로세스에서는 실행 중 플래그로 겹침을 막는다

replica가 하나이고 같은 프로세스 안에서 이전 실행과 다음 실행이 겹치는 문제만 있다면 메모리 플래그로 충분하다.

let running = false;

async function runSettlementTick(): Promise<void> {
  if (running) {
    logger.warn(
      { jobName: "daily-settlement" },
      "job tick skipped because previous run is active",
    );
    return;
  }

  running = true;

  try {
    await settlementService.runDaily();
  } finally {
    running = false;
  }
}

finally가 없으면 작업이 한 번 실패한 뒤 플래그가 영원히 true로 남을 수 있다.

cron.schedule("* * * * *", () => {
  void runSettlementTick().catch((error) => {
    logger.error(
      { error },
      "scheduled settlement failed",
    );
  });
});

콜백이 반환한 Promise를 Cron 라이브러리가 관찰하지 않는 경우를 고려해 오류를 명시적으로 처리한다.

메모리 플래그의 한계

flowchart LR
    A[Pod A running=false] --> C[작업 시작]
    B[Pod B running=false] --> D[작업 시작]

플래그는 프로세스마다 독립적이다. replica가 둘이면 둘 다 false를 보고 실행한다. 프로세스 재시작 시 상태도 사라진다. 따라서 이 방식은 다음 조건에서만 사용한다.

“현재 replica가 하나니까”는 영구 보장이 아니다. 자동 확장, 무중단 배포, 장애 복구 중 일시적으로 두 인스턴스가 존재할 수 있다.

실행 슬롯을 데이터베이스에 선점한다

각 스케줄 실행을 데이터베이스 행으로 표현하면 Unique Constraint가 동시 선점을 중재할 수 있다.

CREATE TABLE job_runs (
  id              bigserial PRIMARY KEY,
  job_name        text NOT NULL,
  schedule_slot   timestamptz NOT NULL,
  partition_key   text NOT NULL DEFAULT '',
  status          text NOT NULL,
  owner_id        text NOT NULL,
  started_at      timestamptz NOT NULL,
  heartbeat_at    timestamptz NOT NULL,
  finished_at     timestamptz,
  error_code      text,
  attempt         integer NOT NULL DEFAULT 1,

  CONSTRAINT job_runs_status_check
    CHECK (
      status IN (
        'running',
        'succeeded',
        'failed',
        'abandoned'
      )
    ),

  CONSTRAINT job_runs_slot_unique
    UNIQUE (
      job_name,
      schedule_slot,
      partition_key
    )
);

두 인스턴스가 같은 슬롯을 삽입해도 하나만 성공한다.

INSERT INTO job_runs (
  job_name,
  schedule_slot,
  partition_key,
  status,
  owner_id,
  started_at,
  heartbeat_at
)
VALUES (
  $1,
  $2,
  $3,
  'running',
  $4,
  now(),
  now()
)
ON CONFLICT (
  job_name,
  schedule_slot,
  partition_key
)
DO NOTHING
RETURNING id;

RETURNING 결과가 있으면 실행권을 얻은 것이다.

type JobClaim = Readonly<{
  runId: string;
  jobName: string;
  scheduleSlot: Date;
  partitionKey: string;
}>;

async function tryClaimJob(
  input: JobIdentity,
  ownerId: string,
): Promise<JobClaim | undefined> {
  const result = await database.query<{
    id: string;
  }>(CLAIM_JOB_SQL, [
    input.jobName,
    input.scheduleSlot,
    input.tenantId ?? "",
    ownerId,
  ]);

  const row = result.rows[0];

  if (!row) {
    return undefined;
  }

  return {
    runId: row.id,
    jobName: input.jobName,
    scheduleSlot: new Date(input.scheduleSlot),
    partitionKey: input.tenantId ?? "",
  };
}
async function executeScheduledJob(
  identity: JobIdentity,
): Promise<void> {
  const claim = await tryClaimJob(
    identity,
    instanceId,
  );

  if (!claim) {
    logger.info(
      identity,
      "job slot already claimed",
    );
    return;
  }

  try {
    await settlementService.run({
      slot: identity.scheduleSlot,
      runId: claim.runId,
    });

    await markJobSucceeded(claim.runId);
  } catch (error) {
    await markJobFailed(
      claim.runId,
      classifyJobError(error),
    );

    throw error;
  }
}

이 방식의 장점

성공 행이 영구적으로 재실행을 막는다

Unique Constraint는 같은 슬롯의 행이 존재하면 재삽입을 막는다. 실패한 실행을 다시 시도하려면 정책이 필요하다.

한 가지 방법은 job_runs를 논리적 슬롯 테이블로 사용하고 attempt 이력을 별도 테이블에 둔다.

CREATE TABLE job_run_attempts (
  id           bigserial PRIMARY KEY,
  job_run_id   bigint NOT NULL
    REFERENCES job_runs(id),
  attempt      integer NOT NULL,
  owner_id     text NOT NULL,
  started_at   timestamptz NOT NULL,
  finished_at  timestamptz,
  status       text NOT NULL,
  error_code   text,

  UNIQUE (job_run_id, attempt)
);

논리적 실행과 물리적 시도를 구분하면 “2025-08-05 정산”은 하나지만 재시도는 여러 번일 수 있음을 표현한다.

ON CONFLICT DO NOTHING은 작업 완료를 보장하지 않는다

실행권을 가진 프로세스가 행을 만든 직후 죽으면 다른 프로세스는 충돌 때문에 실행하지 않는다. 중복을 막았지만 작업이 누락된다. heartbeat와 stale 실행 복구 정책이 필요하다.

heartbeat와 고아 실행 복구

작업 중 주기적으로 소유권이 살아 있음을 갱신한다.

UPDATE job_runs
SET heartbeat_at = now()
WHERE id = $1
  AND owner_id = $2
  AND status = 'running';

일정 시간 이상 heartbeat가 없으면 abandoned 후보로 본다.

SELECT id, job_name, schedule_slot, owner_id
FROM job_runs
WHERE status = 'running'
  AND heartbeat_at < now() - interval '10 minutes';

그러나 느린 작업을 고아로 오판하면 기존 작업과 복구 작업이 동시에 실행될 수 있다. heartbeat 간격, 네트워크 단절, GC pause, 최대 작업 시간을 고려해야 한다. 복구 전에 기존 worker를 확실히 중지시킬 수 없다면 작업은 중복 실행을 견뎌야 한다.

PostgreSQL Advisory Lock으로 실행권을 얻는다

PostgreSQL Advisory Lock은 애플리케이션이 의미를 부여한 정수 키에 잠금을 건다. pg_try_advisory_lock은 즉시 획득하면 true, 이미 누가 보유하고 있으면 기다리지 않고 false를 반환한다.

SELECT pg_try_advisory_lock($1) AS acquired;

세션 수준 잠금은 같은 데이터베이스 세션에서 유지되고, 명시적으로 해제하거나 세션이 끝날 때 해제된다.

async function withAdvisoryLock<T>(
  lockKey: bigint,
  task: () => Promise<T>,
): Promise<
  | { acquired: true; value: T }
  | { acquired: false }
> {
  const client = await pool.connect();

  try {
    const acquiredResult =
      await client.query<{ acquired: boolean }>(
        "SELECT pg_try_advisory_lock($1) AS acquired",
        [lockKey.toString()],
      );

    if (!acquiredResult.rows[0]?.acquired) {
      return { acquired: false };
    }

    try {
      const value = await task();
      return { acquired: true, value };
    } finally {
      await client.query(
        "SELECT pg_advisory_unlock($1)",
        [lockKey.toString()],
      );
    }
  } finally {
    client.release();
  }
}

반드시 같은 DB 세션을 사용한다

다음처럼 풀에 각각 쿼리를 보내면 잠금과 해제가 다른 연결에서 실행될 수 있다.

// 위험한 예
await pool.query(
  "SELECT pg_try_advisory_lock($1)",
  [lockKey],
);

await runJob();

await pool.query(
  "SELECT pg_advisory_unlock($1)",
  [lockKey],
);

세션 잠금을 획득한 연결은 풀에 돌려주지 말고 작업이 끝날 때까지 전용으로 보유해야 한다. 연결이 끊기면 PostgreSQL이 세션 잠금을 해제하므로 프로세스 비정상 종료에 대한 정리는 비교적 단순하다.

잠금 키 충돌을 관리한다

문자열 작업 이름을 64비트 정수로 안정적으로 변환해야 한다. 단순 해시는 서로 다른 작업이 같은 키를 가질 수 있다. 충돌 가능성을 이해하고 중앙 레지스트리나 두 개의 32비트 키 공간을 사용할 수 있다.

const ADVISORY_LOCK_KEYS = {
  dailySettlement: 1_001n,
  expireSessions: 1_002n,
  rebuildSearchIndex: 1_003n,
} as const;

명시적 레지스트리는 작업 수가 적을 때 가장 이해하기 쉽다.

트랜잭션 수준 잠금은 긴 작업에 부적합할 수 있다

pg_try_advisory_xact_lock은 트랜잭션 종료 시 자동 해제된다. 작업 전체를 한 트랜잭션으로 감싸면 잠금은 편하지만, 수십 분짜리 배치가 긴 트랜잭션이 되어 vacuum, 잠금 유지, 장애 복구에 부담을 줄 수 있다.

SELECT pg_try_advisory_xact_lock($1);

짧은 원자적 상태 변경에는 적합할 수 있지만 긴 외부 API 작업에는 세션 락, 실행 행, 전용 작업 큐가 더 나을 수 있다.

Advisory Lock만으로 이력은 남지 않는다

잠금이 해제되면 “오늘 작업이 성공했는가”를 알 수 없다. 실행 이력이 필요하면 job_runs와 함께 사용한다.

Advisory Lock: 지금 동시에 실행되는 것을 막음
Job Run Row: 어떤 슬롯이 언제 어떤 결과로 실행되었는지 기록

임대 기반 분산 락의 만료 문제

Redis 같은 외부 저장소에 TTL이 있는 락을 만들면 프로세스가 죽어도 락이 영원히 남지 않는다.

SET job:daily-settlement owner-123
NX PX 60000

원칙상 락 해제는 자신이 설정한 토큰과 현재 값이 같은지 원자적으로 확인해야 한다. 단순 DEL은 만료 뒤 다른 소유자가 얻은 락을 이전 소유자가 지울 수 있다.

if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end

하지만 더 어려운 문제는 작업 시간이 TTL을 넘는 경우다.

sequenceDiagram
    participant A as Worker A
    participant Lock as Lock Store
    participant B as Worker B
    participant Target as Target System

    A->>Lock: lease 60초 획득
    A->>A: 긴 GC pause 또는 네트워크 단절
    Lock->>Lock: lease 만료
    B->>Lock: 새 lease 획득
    B->>Target: 작업 실행
    A->>Target: 뒤늦게 작업 계속
    Note over A,B: 두 Worker 모두 자신이 소유자라고 행동

TTL 연장을 구현해도 연장 요청이 지연되는 순간이 있다. 외부 부작용을 정말 한 소유자만 수행해야 한다면 증가하는 fencing token을 함께 사용하고 대상 시스템이 오래된 토큰을 거부해야 한다.

Worker A token=41
Worker B token=42

대상 시스템은 마지막 token=42를 기록
뒤늦은 Worker A의 token=41 요청 거부

대상 API가 fencing token을 이해하지 못하면 완전한 상호 배제를 만들기 어렵다. 이 때문에 “락을 걸었으니 정확히 한 번”이라고 표현해서는 안 된다.

분산 락의 보장 수준을 문서화한다

단일 Redis 인스턴스의 가용성, 장애 조치 중 동작, 시계 가정, TTL 연장, 네트워크 분할에 따라 보장이 달라진다. 검증된 라이브러리를 사용하더라도 업무가 요구하는 안전성 수준과 맞는지 확인한다.

전용 스케줄러와 Worker로 역할을 나눈다

API 서버 안에 Cron을 넣으면 replica 수와 작업 실행 수가 결합된다.

API replica 증가
→ 스케줄러 수도 증가
→ 중복 방지 부담 증가

스케줄 트리거와 실행을 분리할 수 있다.

flowchart LR
    S[Single Scheduler] -->|job message| Q[Durable Queue]
    Q --> W1[Worker 1]
    Q --> W2[Worker 2]
    Q --> W3[Worker 3]
    W1 --> DB[(Database)]
    W2 --> DB
    W3 --> DB

스케줄러는 실행 메시지를 내구성 있는 큐에 넣고 Worker가 소비한다. 메시지 ID 또는 실행 슬롯을 중복 제거 키로 사용한다.

await jobQueue.publish({
  messageId:
    "daily-settlement:2025-08-05",
  jobName: "daily-settlement",
  scheduleSlot:
    "2025-08-05T00:00:00.000Z",
});

큐가 at-least-once 전달을 제공하면 같은 메시지가 다시 올 수 있으므로 Worker 멱등성은 여전히 필요하다.

Kubernetes CronJob을 사용하면 API Pod와 스케줄 실행을 분리할 수 있고 concurrencyPolicy로 같은 CronJob의 겹침을 제어한다.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-settlement
spec:
  schedule: "0 2 * * *"
  timeZone: "Asia/Seoul"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: worker
              image: example.invalid/settlement-worker:2025-08-05
              args:
                - run
                - daily-settlement

Forbid는 이전 Job이 아직 실행 중이면 새 실행을 건너뛴다. Replace는 이전 실행을 대체하고, Allow는 동시 실행을 허용한다. 이것도 CronJob 컨트롤러 수준의 겹침 정책이지 외부 부작용의 exactly-once 보장은 아니다. 컨트롤러 장애나 재시도, 수동 Job 실행까지 고려해 작업은 멱등하게 둔다.

전용 Worker의 장점

작업이 아주 작고 인프라 복잡도를 늘리고 싶지 않다면 DB 실행 슬롯만으로도 충분할 수 있다. 전용 시스템 도입은 실행 시간, 빈도, 복구 요구, 작업 수에 맞춰 결정한다.

중복 방지와 멱등성은 별개의 안전장치다

락은 동시에 둘이 들어오는 것을 줄이지만 다음 상황을 완전히 없애지 못한다.

1. Worker A가 외부 송금 API 호출 성공
2. 성공 상태를 DB에 기록하기 전에 프로세스 종료
3. 복구 Worker B는 실패로 보고 작업 재실행
4. 외부 송금 API를 다시 호출

실행권은 한 번씩 얻었지만 부작용은 두 번 발생할 수 있다.

외부 API Idempotency Key

외부 서비스가 지원한다면 논리 실행 ID를 키로 전달한다.

await bankClient.transfer({
  accountId: settlement.accountId,
  amount: settlement.amount,
  idempotencyKey:
    `settlement:${settlement.id}`,
});

재시도해도 외부 시스템이 같은 키의 결과를 반환해야 한다.

데이터베이스 Unique Constraint

CREATE TABLE settlement_results (
  settlement_date date NOT NULL,
  account_id      bigint NOT NULL,
  amount          numeric(18, 2) NOT NULL,
  created_at      timestamptz NOT NULL,

  PRIMARY KEY (
    settlement_date,
    account_id
  )
);
INSERT INTO settlement_results (
  settlement_date,
  account_id,
  amount,
  created_at
)
VALUES ($1, $2, $3, now())
ON CONFLICT (
  settlement_date,
  account_id
)
DO NOTHING;

상태 전이 조건

UPDATE invoices
SET
  status = 'expired',
  expired_at = now()
WHERE due_at < now()
  AND status = 'pending';

이미 expired인 행은 다시 변경하지 않는다. 처리 행 수를 기록해 재실행 결과를 확인할 수 있다.

덮어쓰기 가능한 결과

매번 동일 입력으로 동일 결과를 계산한다면 임시 테이블에 만든 뒤 원자적으로 교체하거나 Upsert한다.

INSERT INTO daily_metrics (
  metric_date,
  metric_name,
  metric_value
)
VALUES ($1, $2, $3)
ON CONFLICT (
  metric_date,
  metric_name
)
DO UPDATE
SET metric_value = EXCLUDED.metric_value;
가장 안전한 조합

실행 슬롯이나 락으로 불필요한 중복을 줄이고, Unique Constraint·조건부 갱신·Idempotency Key로 중복 실행의 부작용을 막는다. 둘 중 하나만 믿지 않는다.

실패한 작업을 다시 실행할 수 있게 한다

중복을 막는 데만 집중하면 실패 후 아무도 실행할 수 없는 상태가 생긴다.

running 행 존재
작업 프로세스 종료
새 프로세스는 “이미 실행 중”으로 판단
영구 누락

실행 상태 머신을 명시한다.

stateDiagram-v2
    [*] --> Pending
    Pending --> Running: claim
    Running --> Succeeded: complete
    Running --> Failed: known failure
    Running --> Abandoned: heartbeat expired
    Failed --> Running: retry
    Abandoned --> Running: recover
    Succeeded --> [*]

재시도에는 최대 횟수와 다음 시각을 둔다.

UPDATE job_runs
SET
  status = 'pending',
  next_attempt_at =
    now() + make_interval(
      secs => LEAST(
        3600,
        power(2, attempt)::integer * 30
      )
    ),
  attempt = attempt + 1
WHERE id = $1
  AND status IN ('failed', 'abandoned')
  AND attempt < $2;

무한 재시도는 영구 오류로 자원을 소모한다. 설정 오류, 인증 실패, 데이터 검증 오류처럼 재시도로 회복되지 않는 오류는 즉시 수동 검토 대상으로 보낸다.

수동 재실행 API도 논리 실행 ID를 받도록 한다.

POST /internal/jobs/daily-settlement/retry
Content-Type: application/json

{
  "scheduleSlot": "2025-08-05",
  "reason": "dependency recovered"
}

새 날짜로 위장해 실행하면 중복 방지 키와 감사 이력이 깨진다. 같은 논리 실행의 새 attempt로 기록한다.

시간대와 스케줄 슬롯을 명시한다

“매일 새벽 2시”는 시간대 없이 완전한 규칙이 아니다.

Asia/Seoul 02:00
UTC 02:00
서버 로컬 시간 02:00

서버 이미지의 시간대나 오케스트레이터 기본값이 바뀌면 실행 시각이 달라질 수 있다. 스케줄 시간대와 업무 기준일을 분리해서 기록한다.

type ScheduledRun = Readonly<{
  scheduledAt: Date;       // 절대 시각 UTC
  businessDate: string;    // 예: 2025-08-05
  scheduleTimeZone: string; // Asia/Seoul
}>;

DB 키에는 모호한 로컬 문자열 대신 정규화한 슬롯을 사용한다.

scheduled_at = 2025-08-04T17:00:00Z
business_date = 2025-08-05
time_zone = Asia/Seoul

일광 절약 시간제를 쓰는 지역에서는 특정 로컬 시간이 존재하지 않거나 두 번 나타날 수 있다. 스케줄러가 이를 어떻게 처리하는지 확인하고, 중복 방지 키는 실제 예정 시각 또는 명시적 비즈니스 날짜 규칙에 기반해야 한다.

서버가 꺼져 있던 슬롯

02:00에 시스템이 꺼져 있고 02:10에 복구되었다면 누락을 실행할지 건너뛸지 정해야 한다.

catch-up: 허용 지연 내 누락 슬롯 실행
skip: 다음 정규 슬롯까지 기다림
manual: 운영자 승인 후 실행

정산처럼 누락되면 안 되는 작업은 스케줄러의 현재 tick만 믿지 않고 실행 이력에서 빈 슬롯을 조회한다.

SELECT expected.business_date
FROM expected_settlement_dates AS expected
LEFT JOIN job_runs AS runs
  ON runs.job_name = 'daily-settlement'
 AND runs.business_date = expected.business_date
 AND runs.status = 'succeeded'
WHERE expected.business_date BETWEEN $1 AND $2
  AND runs.id IS NULL;

관측과 운영 절차

중복을 조용히 건너뛰기만 하면 설정 오류를 발견하기 어렵다.

실행 로그

{
  "jobName": "daily-settlement",
  "scheduleSlot": "2025-08-05",
  "runId": "run_example_42",
  "ownerId": "worker-example-a",
  "attempt": 1,
  "event": "job_claimed"
}
{
  "jobName": "daily-settlement",
  "scheduleSlot": "2025-08-05",
  "event": "job_claim_skipped",
  "reason": "slot_already_claimed"
}

정상적인 여러 replica 경쟁이라면 skip 로그가 매번 발생할 수 있다. 로그 레벨과 샘플링을 조절하고, 예상보다 많은 claim 시도가 있는지 메트릭으로 본다.

메트릭

runId, scheduleSlot, ownerId처럼 값이 계속 늘어나는 필드는 메트릭 라벨로 쓰지 않고 로그에 둔다.

알림

단순 실패 횟수보다 업무 의미가 있는 조건이 좋다.

- 예정 시각 + 30분이 지나도 성공 실행이 없음
- 실행 시간이 평소 p95의 2배 초과
- abandoned 실행 발견
- 최대 재시도 횟수 도달
- 같은 슬롯에서 외부 부작용 중복 감지

종료 처리

SIGTERM을 받으면 새 작업 선점을 중단하고 실행 중인 작업을 grace period 안에서 마무리한다.

let acceptingJobs = true;

process.once("SIGTERM", () => {
  acceptingJobs = false;
  void shutdownWorker();
});

async function scheduledTick(): Promise<void> {
  if (!acceptingJobs) {
    return;
  }

  await tryRunDueJob();
}

작업이 grace period보다 길다면 체크포인트와 재실행 안전성이 필요하다. 자세한 내용은 배치 작업을 재실행 가능하게 만드는 체크포인트에서 이어진다.

선택 기준

상황 우선 고려 이유
단일 프로세스, 짧은 작업 메모리 running 플래그 구현이 가장 단순
여러 replica, 짧은 DB 중심 작업 실행 슬롯 + Unique Constraint 원자적 선점과 이력
여러 replica, 한 번에 하나의 긴 작업 세션 Advisory Lock + 실행 이력 연결 종료 시 잠금 해제
많은 작업과 재시도 필요 내구성 큐 + Worker 실행 생명주기와 재시도 분리
Kubernetes에서 독립 배치 CronJob + Job API와 자원·배포 분리
외부 부작용 안전성이 매우 중요 위 방법 + Idempotency Key/제약 락 만료·재시도에도 부작용 보호

결정 전에 다음 질문에 답한다.

복잡한 분산 락을 먼저 선택하기보다, 작업을 전용 큐로 옮기거나 DB의 원자적 제약으로 문제를 단순화할 수 있는지 본다.

마무리

Cron 작업의 중복은 표현식의 문제가 아니다. 여러 프로세스, 긴 실행 시간, 재시도, 타임아웃, 수동 복구가 같은 논리 작업을 다시 시작하게 만드는 실행 모델의 문제다.

단일 프로세스 겹침에는 메모리 플래그, 여러 프로세스의 실행권에는 DB 실행 슬롯이나 Advisory Lock, 복잡한 재시도에는 전용 큐와 Worker가 적합하다. 그러나 어떤 잠금도 프로세스 중단 뒤의 중복 부작용까지 완전히 없애지는 못하므로 작업 자체를 멱등하게 만들어야 한다.

실행 슬롯, 시도 이력, heartbeat, 시간대, 누락 보충 정책을 명시하면 “한 번만 돌아가길 기대하는 Cron”이 아니라 실패 후에도 설명하고 복구할 수 있는 배치 시스템이 된다.

참고 자료

관련 노트